Skip to content

[PM-33500] - delete attachments from deleted ciphers#7208

Open
jaasen-livefront wants to merge 5 commits intomainfrom
PM-33500
Open

[PM-33500] - delete attachments from deleted ciphers#7208
jaasen-livefront wants to merge 5 commits intomainfrom
PM-33500

Conversation

@jaasen-livefront
Copy link
Collaborator

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-33500

📔 Objective

Fix orphaned attachment files left behind when ciphers are deleted in bulk, an organization vault is purged, or an organization is deleted.

When a single cipher is deleted via CipherService.DeleteAsync, attachment files are properly removed from blob/disk storage via DeleteAttachmentsForCipherAsync. However, three other deletion paths only removed DB records, leaving attachment files permanently orphaned in storage:

  1. CipherService.DeleteManyAsync (bulk cipher delete) — deleted cipher rows but never cleaned up attachment storage.
  2. CipherService.PurgeAsync (org vault purge) — deleted cipher rows but never cleaned up attachment storage.
  3. OrganizationDeleteCommand.DeleteAsync (org deletion) — cascade-deleted cipher rows via SQL stored proc but never cleaned up attachment storage.

Changes

  • CipherService.DeleteManyAsync: After DB deletion, iterate deleted ciphers and call DeleteAttachmentsForCipherAsync for each.
  • CipherService.PurgeAsync: Fetch org cipher IDs before DB deletion, then clean up each cipher's attachments after.
  • OrganizationDeleteCommand.DeleteAsync: Inject ICipherRepository and IAttachmentStorageService. Fetch org cipher IDs before DB cascade delete, then clean up each cipher's attachments after.
  • Updated unit tests for DeleteManyAsync (org admin + personal owner paths) and OrganizationDeleteCommand.Delete_Success to assert attachment cleanup. Added new PurgeAsync_WithOrganizationId_DeletesCiphersAndAttachments test.

📸 Screenshots

@github-actions
Copy link
Contributor

github-actions bot commented Mar 13, 2026

Logo
Checkmarx One – Scan Summary & Details96c85c1f-31f0-4e62-9cc9-50c1a20d78a4


New Issues (2) Checkmarx found the following issues in this Pull Request
# Severity Issue Source File / Package Checkmarx Insight
1 MEDIUM CSRF /src/Api/Vault/Controllers/CiphersController.cs: 1592
detailsMethod at line 1592 of /src/Api/Vault/Controllers/CiphersController.cs gets a parameter from a user request from id. This parameter value flows ...
Attack Vector
2 MEDIUM CSRF /src/Api/Vault/Controllers/CiphersController.cs: 1419
detailsMethod at line 1419 of /src/Api/Vault/Controllers/CiphersController.cs gets a parameter from a user request from id. This parameter value flows ...
Attack Vector

Fixed Issues (1) Great job! The following issues were fixed in this Pull Request
Severity Issue Source File / Package
MEDIUM CSRF /src/Api/Vault/Controllers/CiphersController.cs: 293

@codecov
Copy link

codecov bot commented Mar 13, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 57.69%. Comparing base (753c8c8) to head (2cac61e).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7208      +/-   ##
==========================================
+ Coverage   57.68%   57.69%   +0.01%     
==========================================
  Files        2035     2035              
  Lines       89645    89663      +18     
  Branches     7993     7997       +4     
==========================================
+ Hits        51709    51733      +24     
+ Misses      36072    36065       -7     
- Partials     1864     1865       +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@gbubemismith gbubemismith added the ai-review Request a Claude code review label Mar 18, 2026
@claude
Copy link
Contributor

claude bot commented Mar 18, 2026

Claude finished @jaasen-livefront's task in 2m 26s —— View job


Code Review: PR #7208 — Delete attachments from deleted ciphers

  • Gather context and read changed files
  • Review code changes for correctness, security, and best practices
  • Post review findings

Result: Clean — No Issues Found

Overall Assessment: APPROVE

This PR fixes orphaned attachment files left behind when ciphers are bulk-deleted, an organization vault is purged, or an organization is deleted. The latest revision (addressing prior reviewer feedback) cleanly centralizes attachment cleanup into a new DeleteAttachmentsForOrganizationAsync method on ICipherService, which:

  • Fetches org ciphers, filters to only those with attachments, and selects just the IDs
  • Iterates and calls DeleteAttachmentsForCipherAsync for each
  • Is called from OrganizationDeleteCommand, DeleteManyAsync, and PurgeAsync

This eliminates the repeated for-loops that earlier reviewers flagged and keeps OrganizationDeleteCommand clean by delegating storage cleanup to the cipher service. Dependencies in OrganizationDeleteCommand were simplified — ICipherRepository and IAttachmentStorageService are no longer directly injected there.

Test coverage is thorough with updated assertions for the org delete path and a new dedicated test (PurgeAsync_WithOrganizationId_DeletesCiphersAndAttachments) verifying that only ciphers with attachments trigger storage deletion calls.

Copy link
Contributor

@gbubemismith gbubemismith left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a few suggestions

}

// Fetch cipher IDs before DB deletion so we can clean up attachment storage
var orgCiphers = await _cipherRepository.GetManyByOrganizationIdAsync(organization.Id);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can improve this a bit. We can just filter and get ciphers with attachments and only return the ciphers Ids

Suggested change
var orgCiphers = await _cipherRepository.GetManyByOrganizationIdAsync(organization.Id);
var cipherIdsWithAttachments =
(await _cipherRepository.GetManyByOrganizationIdAsync(organization.Id))
.Where(c => c.GetAttachments()?.Count > 0)
.Select(c => c.Id);

}

// Fetch cipher IDs before DB deletion so we can clean up attachment storage
var orgCiphers = await _cipherRepository.GetManyByOrganizationIdAsync(organizationId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can do same filtering here and only get ciphers with attachments and select the cipher ids.


foreach (var cipher in orgCiphers)
{
await _attachmentStorageService.DeleteAttachmentsForCipherAsync(cipher.Id);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine, but I think it'd be nicer if we followed the format above and added a method such like DeleteAttachmentsForOrganization then supply only the organization ID like we do for the above applicationCacheService.

The for loop can then live in that method and this file becomes a bit cleaner

// Clean up attachment files from storage
foreach (var cipher in deletingCiphers)
{
await _attachmentStorageService.DeleteAttachmentsForCipherAsync(cipher.Id);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By doing what I suggested above, we can probably also get rid of another for loop here as well


foreach (var cipher in orgCiphers)
{
await _attachmentStorageService.DeleteAttachmentsForCipherAsync(cipher.Id);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By having 1 spot where we only supply the organization ID to pass, we can get rid of all these for loops! 😄

Copy link
Contributor

@JaredScar JaredScar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good stuff! Just a few small cleanups and should be good to go

@jaasen-livefront
Copy link
Collaborator Author

@gbubemismith @JaredScar I've addressed all the feedback in 834a1b9. Thanks!

Comment on lines +514 to +515
var ciphersWithAttachments = (await _cipherRepository.GetManyByOrganizationIdAsync(organizationId))
.Where(c => c.GetAttachments()?.Count > 0);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jaasen-livefront, is there a reason we will need the whole cipher object? since we just need the cipher Ids, we can add a .Select(c => c.Id) to this

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gbubemismith Good point! Fixed 2cac61e

@sonarqubecloud
Copy link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants